Skip to content

Conversation

@wjones127
Copy link
Contributor

@wjones127 wjones127 commented Nov 18, 2025

BREAKING CHANGE: The SearchResult struct returned by ScalarIndex::search() now wraps a NullableRowIdSet instead of a RowIdTreeMap. Scalar indices must now provide the set of row ids where the expression value is null instead of just where it is true. Additionally, the RowIdMask is now an enum instead of a struct.

This PR fixes correctness bugs that show up when (a) running a filter with NOT, (b) the column you are filtering on contains nulls, and (c) we are using a scalar index (such as btree, or bitmap). Previously, this would give the wrong answer:

import pyarrow as pa
import lance

data = pa.table({"value": [1, 5, None]})
ds = lance.write_dataset(data, "memory://")
ds.create_scalar_index("value", "BTREE")
ds.to_table(filter="NOT (value < 2)")
pyarrow.Table
value: int64
----
value: [[5,null]]

It should not include null. The reason it did is that our RowIdMask (which is output by a scalar index query) was not aware of nulls. So when it processed value < 2, it would select just row index 0. Then NOT would invert that to [1, 2], selecting both [false, null].

This PR makes RowIdMask aware of nulls. When it processes value < 2, it records selected: [0] and nulls: [2]. Then, when you invert that and then drop, you get selected: [1], giving the correct final answer of just [5].

As part of this, we also refactor RowIdMask to make allow list and deny list mutually exclusive, which simplifies some of the logic.

Fixes #4756

wjones127 and others added 2 commits November 17, 2025 17:36
Scalar indexes currently treat NULL values as FALSE when evaluating
filters, which violates Kleene three-valued logic. This causes bugs
like `x != 5` incorrectly including rows where x is NULL.

This commit adds the foundation for proper null handling:
- Extended RowIdMask with null_list field to track NULL rows
- Implemented Kleene logic for NOT, AND, OR operations
- Updated SearchResult to carry null row information
- Modified all index implementations for backward compatibility

The infrastructure is complete but indexes don't yet track nulls.
Follow-up commits will implement actual null tracking in BTree and
Bitmap indexes.

Fixes lance-format#4756

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@wjones127 wjones127 added the critical-fix Bugs that cause crashes, security vulnerabilities, or incorrect data. label Nov 18, 2025
@github-actions github-actions bot added bug Something isn't working python labels Nov 18, 2025
wjones127 and others added 13 commits November 18, 2025 11:02
Added unit tests to verify Kleene three-valued logic operations work correctly:
- RowIdMask AND/OR/NOT operations with nulls
- also_block and also_allow preserve null_list
- Serialization/deserialization of null_list
- Bitmap index returns null_list in SearchResult

These tests verify the null tracking infrastructure works correctly at the Rust level.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Adds comprehensive tests to verify that bitmap and btree indexes correctly
track and return null row IDs in query results.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
The [tool.ruff] section had a duplicate lint key which caused maturin
to fail parsing the file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
After adding null_list to RowIdMask, the serialization now produces 3
elements (block_list, allow_list, null_list) instead of 2. Updated
serialize_to_arrow and try_from_arrow to handle 3 elements correctly.

This fixes the "all columns in a record batch must have the same length"
error when using scalar indexes with null tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
The iter_ids() method was only filtering out block_list entries but not
null_list entries. This caused nulls to be included in query results when
they should be filtered out according to Kleene three-valued logic.

Updated iter_ids() to filter out both block_list and null_list entries,
ensuring that null values are never returned in iteration.

Added test_iter_ids_with_nulls() to verify the fix.

Note: The Python integration test still fails, indicating there may be
another code path that needs fixing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
@wjones127 wjones127 changed the title fix: null handling with scalar indices fix: null handling when using NOT with scalar indices Nov 19, 2025
@wjones127 wjones127 changed the title fix: null handling when using NOT with scalar indices fix!: null handling when using NOT with scalar indices Nov 20, 2025
@wjones127 wjones127 marked this pull request as ready for review November 21, 2025 16:43
@wjones127 wjones127 requested a review from westonpace November 21, 2025 16:44
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Previously, NOT operations on inexact index results (AtMost/AtLeast)
would keep the same certainty variant while negating the mask. This
was incorrect because:

- NOT(AtMost(x)) should return AtLeast(!x), not AtMost(!x)
  (complement of superset is subset)
- NOT(AtLeast(x)) should return AtMost(!x), not AtLeast(!x)
  (complement of subset is superset)

This caused incorrect query results when using inexact indices like
bloom filters and zonemaps with NOT operators.

Changes:
- Fixed NOT implementation to flip certainty variants
- Removed unused `map` method that was replaced by explicit match
- Added unit tests for certainty flipping
- Added integration tests for NOT with nulls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change bug Something isn't working critical-fix Bugs that cause crashes, security vulnerabilities, or incorrect data. python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scalar index queries handle NOT and NULL incorrectly

1 participant